Chord
Chord(SIGCOMM 2001)是这批四篇里最基础的一篇,也是把 一致性哈希 从"需要每个节点知道几乎所有其他节点"改造成"每个节点只需要
Chord 协议只支持一个操作:给定一个 key,把它映射到一个节点上。 至于那个节点是否负责存这个 key 对应的值,取决于用 Chord 的应用。
三条卖点:简单、可证明正确、可证明性能。其中"可证明正确"有个很值得记住的强弱:
对保证查询的正确(虽然慢)路由而言,每个节点只需要一条信息是正确的 —— 它的 successor 指针。
系统模型:五个要解决的问题与一条职责边界
它覆盖的五个问题:负载均衡(Chord 充当一个分布式哈希函数)、去中心化(没有节点比别的更重要)、扩展性(查找代价随节点数取对数,且不需要任何参数调节)、可用性(自动调整内部表以反映新加入与失败,即使系统持续变化)、键空间扁平(对 key 的结构不加任何约束,于是应用在把自己的名字映射到 Chord key 时有很大自由度)。
职责边界是这一节里最容易被忽略但最要紧的一点。Chord 以库的形式提供,应用与它只有两个交互面:lookup(key) 返回负责该 key 的节点的 IP 地址;每个节点上的 Chord 软件把"该节点负责的 key 集合发生了变化"通知应用(这样应用可以在新节点加入时把对应的值搬过去)。而
认证、缓存、复制、以及用户友好的命名,都由使用 Chord 的应用自己负责。
换句话说,Chord 本身不做数据复制 —— 后面那个 successor list 是为了保证环的连通性,与数据副本是两件事。应用侧可以自己复制,办法是把同一份数据存在两个不同的 Chord key 下。
一致性哈希:m 位标识符与 successor
一个节点和一个 key 各被分配一个
key
被分配给"标识符等于 、或在标识符空间中紧随 之后"的第一个节点。这个节点称为 的 successor,记作 。
一个
加入与离开的搬动量(引自一致性哈希那两篇):
定理 4.1:对任意
个节点与 个 key 的集合,以高概率成立:① 每个节点最多负责 个 key;② 当第 个节点加入或离开时,只有 个 key 的归属发生变化(且只涉及这个加入或离开的节点)。
对 ② 还有一句定性:这显然是为了维持负载均衡所必需的最小搬动量。而 ① 里的
「以高概率」这个措辞的处理方式
这一小段很短,但我觉得是 Chord 与后来许多工程系统分道扬镳的地方,值得单独记:
一致性哈希原稿用
我们不再宣称定理"以高概率"成立,而改为宣称它们**"基于标准困难性假设"成立**。
代价是负载:为简化(主要是为了叙述)虚拟节点的使用被放弃,此时一个节点的负载可能以高概率(或按上述,基于标准困难性假设)超出平均值最多
第一步:只靠 successor 指针的朴素查找
最省状态的查找是每个节点只知道自己当前的 successor:查询沿着 successor 指针在环上一跳一跳传递,直到遇到一对"跨住"目标标识符的节点,这对里的第二个就是查询要映射到的节点。
这一版的代价是消息数与节点数成线性,但它是整个协议的地基:只要每个节点知道正确的 successor,正确性就成立。
finger table:把线性变对数
每个节点
即从
这个结构有两个特征:
- 每个节点只存少数其他节点的信息,而且对环上紧随其后的节点知道得比远处的更细;这一点是它的代价来源 ——
- 一张 finger table 通常不足以直接判定任意 key 的 successor。例子很干脆:图里的 node 8 无法自己确定 key 34 的 successor,因为那个 successor(node 38)根本不在 node 8 的 finger table 里。
查找时,id 是否落在 id"的那个节点 id 越近,它对 id 所在那一段环就知道得越多。
例子:node 8 查 key 54 → node 8 里前于 54 的最大 finger 是 node 42 → 转给 42;42 里前于 54 的最大 finger 是 node 51 → 转给 51;51 发现自己的 successor(node 56)跨过了 54,返回 56。
「折半」这个论证
因为 finger 项按 2 的幂分布在环上,每个节点能把查询沿"到目标的剩余距离"至少推进一半。这个证明值得跟一遍:
设
要查 的 successor, 是紧邻 之前的节点。设 落在 的第 个 finger 区间内。这个区间非空,所以 会指向该区间里的某个节点 。 到 的距离至少是 ;而 与 同处 的第 个 finger 区间内,所以它们之间的距离至多是 。于是 到 的距离至多是 到 距离的一半。
每步折半、初始距离至多
定理 4.2:以高概率(或在标准困难性假设下),在
个节点的网络中,为找到一个 successor 需要接触的节点数是 。
加入与稳定化
join(n')(predecessor = nil、请 build_fingers(s)、successor = s。注意 join() 自己并不会让网络其余部分知道
真正让别人知道的机制是每个节点周期性地跑 stabilize():
stabilize():问自己的 successor 要它的 predecessor,判断 是否应该成为自己的 successor(如果 是刚加入的就会这样);然后** successor.notify(n)**,给 successor 一个机会把 predecessor 改成自己;notify(n'):若 predecessor 为空、或,则把 predecessor 设为 。
一个刚加入的、还没被任何 finger 指到的新节点会让查询短暂地"打偏"(undershoot),但查找算法里的循环会顺着 successor(即 finger[1])指针穿过这些新节点直到正确的 predecessor;等 finger table 被修好,这种线性扫描就不再需要。
定理 4.3:若把任意序列的 join 操作与 stabilization 交错执行,那么在最后一次 join 之后的某个时刻,successor 指针会在网络中所有节点上形成一个环。
稳定化没完成时查找的三种行为
| 情形 | 结果 |
|---|---|
| 常见情形:涉及的 finger 项还比较新 | |
| successor 指针正确,但 finger 不准确 | 查找仍然正确,但可能更慢 |
| 受影响区域的节点 successor 指针不正确,或 key 还没迁到新加入的节点 | 查找可能失败,上层软件会发现取不到数据,可以停顿一下重试(这个停顿可以很短,因为稳定化很快就修好 successor 指针) |
这里有一个看起来反直觉的性质:finger 项能把查询送出很远,这件事并不取决于它指向的到底是哪个节点 —— 折半论证只依赖标识符空间里的距离。所以 finger 过期并不会显著拖慢查找。新加入节点影响速度的唯一主要途径是它的 ID 落在目标 predecessor 与目标之间,此时查询得一个个穿过去;除非有极大量节点同时加入,两个老节点之间的新节点通常很少。形式化后就是:
定理 4.4:取一个含
个节点的稳定网络,再加入最多 个节点,若所有 successor 指针正确(但 finger 指针未必),那么查找仍以高概率耗时 。 更一般地:只要把 finger 调整好所花的时间少于网络规模翻倍所花的时间,查找就一直是
跳 —— 这可以通过反复执行查找来更新 finger 达成,于是只要在任意 次节点加入之间发生 轮稳定化,查找的表现就良好。
失败、successor list 与自愿离开
正确性依赖"每个节点知道自己的 successor"这条不变量,而节点失败会破坏它。 一个例子很具体:若节点 14、21、32 同时失败,node 8 就不知道 node 38 现在是它的 successor(因为它没有任何 finger 指向 38)—— 于是一个发给 node 8 的 key 30 查询会错误地返回 node 42,而不是正确的 successor node 38。
对策是每个节点维护一个长度为
列表的维护方式是:节点 stabilize 会把指向失败节点的 finger 与 successor list 项都修掉。另外,closest_preceding_node 要同时搜 finger table 与 successor list;find_successor 中途遇到节点失败时,超时后从 finger table 与 successor list 里试下一个最好的 predecessor。
定理 4.5:在一个初始稳定的网络中,若使用长度
的 successor list、然后每个节点以概率 失败,那么以高概率 find_successor仍返回查询 key 最近的存活 successor。证明只有一句:失败前每个节点知道自己的
个后继,这 个全部失败的概率是 。
自愿离开可以直接当成失败处理,但有两条增强:① 离开前把 key 交给自己的 successor;② 离开前通知自己的 predecessor
正确性分析(一):lookup 最终一定会成功
第 4 章的定理只在几个简单模型下成立(节点停止加入后的最终稳定、稳定环面对失败)。第 5 章放宽到节点持续加入与离开的模型,要证两件事:系统保持稳定,且查找继续可用且快。全章都建立在一条通信假设上:任意两个试图通信的节点最终都能成功。
两条"最终会好"的定理先立起来:
定理 5.1:一旦某个节点能成功解析某个查询,它此后将永远能解析该查询。
定理 5.2:在最后一次加入之后的某个时刻,所有 successor 指针都会是正确的。
它们的证明靠一条不变量 + 一个终止性论证。不变量是"一旦节点
形式化这个论证需要先定义两个概念:
| 定义 | 内容 |
|---|---|
| 5.3 reachable | 从 |
| 5.4 arc path | 从 |
"只经过
引理 5.5:若某时刻
存在从 到 的 arc path,则其后所有 时刻都存在从 到 的 arc path。
证明是对时间做归纳(把时间看作"successor / predecessor 指针的变化次数")。原证明里最关键的两步:
- 节点加入会建立一条 successor 指针,让它能到达此前到不了的节点,但显然不破坏任何已有的 arc path。
- 稳定化这一步要论证。设节点
把 successor 从 改成 —— 这只可能是因为 联系了 并听说了 ,且 。那么在更早的某个时刻, 得知了 ,而这只可能是 告诉了 关于自己的信息,而这又只可能在某个更早时刻 的 successor 是 时发生 —— 在那个时刻,存在从 到 的 arc path(就是那条 successor 链)。由归纳,在 把指针改向 之前,从 到 的 arc path 仍然存在。而因为 ,这条从 到 的 arc path 不可能包含 —— 于是 改指针这件事没有扰动它。把边 与这条 arc path 接起来,就得到一条从 到 的 arc path。 - 最后还要处理"别的 arc path 里用到了
这条边"的情形:既然那条路径是 arc path, 与 必然都在它的端点 与 之间;而刚刚证明了新的 路径上所有节点都在 与 之间,因此也都在 与 之间 —— 于是那条从 到 的路径仍然是 arc path。
两条推论接着用:
推论 5.6:若时刻
存在从 到 的 successor arc 路径,则所有 时刻仍存在。证明:由上一条引理,路径上每条 successor arc 只会被"一条路径"替换、不会被断开;把这些替换路径接起来就是从 到 的路径。
推论 5.7:设
是 Chord 网络中第一个节点,则在任何时刻,每个节点都能经 successor 指针到达 。证明对加入次数做归纳:新节点加入时其 successor 指针指向一个能到达 的节点,故新节点也能到达 ;再由上一条,既然新节点起初能到达 ,它就永远能。
有了这两条才能收口:
定理 5.8:若把任意序列的 join 操作与 stabilization 交错执行,则在最后一次 join 之后的某个时刻,successor arc 会在网络中所有节点上形成一个环。
证明:注意到若两个节点共享同一个 successor,其中一个最终会改自己的 successor 指针;它的新 successor 在环上比旧的更近,所以它的 successor 指针最多变化
次。于是在 步之后,必然进入一个稳定状态:每个节点至多是(因而恰好是)一个节点的 successor。而每个节点又恰好有一个 successor,所以满足"入度 1 且出度 1"的图结构只能是一组环。但由不变量,每个节点都能到达网络中最早出现的那个节点 —— 所以这组环只能恰好是一个环。
加入对查找性能的影响:finger 为什么不慌
一个反直觉的结论:节点加入时 finger 的调整不需要专门讨论,因为加入并不会实质伤害 finger 的性能。
若一个节点在每个区间都有一项 finger,那么即便有节点加入,这些 finger 仍然可用。折半论证基本不变,仍能说明
跳就足以到达"接近"查询目标的节点。
新加入节点影响查找的唯一途径是它落在目标查询的旧 predecessor 与 successor 之间 —— 这些新节点可能需要被线性扫过(如果它们的 finger 还不准)。但除非有极大量节点同时加入,两个老节点之间的新节点很可能非常少,因此影响可以忽略。形式化后:
定理 5.9:取一个含
个节点的稳定网络,再加入最多 个**没有 finger 指针(但 successor 指针正确)**的节点,则查找仍以高概率耗时 。 证明:原有那批 finger 会在
时间内把查询带到正确节点的旧 predecessor;而以高概率,任意两个老节点之间落下的新节点至多 个 —— 所以从旧 predecessor 走到新 predecessor,沿 successor 指针只需要穿过 个新节点。
这个结论可以推广成一条更好用的判据:只要"调整 finger 所花的时间"小于"网络规模翻倍所花的时间",查找就会一直是
只要在任意
次节点加入之间发生 轮稳定化,查找的表现就良好。
强稳定化:weak / strong / loopy 三个定义
这是全篇最深的一段,常规的 Chord 介绍几乎都不提。先看 stabilize() 保证的性质:
stabilize()试图保证的,是对任意节点都有 。这是一个局部一致性条件,对 Chord 网络的正常行为而言必要但不充分。
一个反例:某个 Chord 网络在这个协议下是稳定的,但全局不一致 —— 事实上不存在任何节点
| 名称 | 定义 |
|---|---|
| 弱稳定(weakly stable) | 对所有 |
| 强稳定(strongly stable) | 在弱稳定基础上,对每个 |
| 环套(loopy) | 弱稳定但非强稳定 |
之所以要往强稳定这一层深挖,有两条很务实的警惕:实现里的一个 bug 可能导致环套状态;或者模型本身失效 —— 例如某节点失联太久,以致一些节点认为它已失败,而它自己仍确信自己活着,这种相互矛盾的意见可能把系统带进奇怪的状态。所以需要的是一个能从任意状态稳定下来的协议,哪怕那个状态不可能由协议的正确运行产生。
核心操作是 self-search:节点 successor[0]、successor[1])并加一个 on cycle 标志,用自查找把环套的环"解开"(unfurl)。
这个协议的边界也划得很清:
该协议不试图重连一个已经断开的网络;那要依赖某种外部手段。
这条限制在 Future Work 里被再确认了一次:Chord 目前没有专门的机制去自愈被分区的环,因为这种环对稳定化过程来说可能是局部一致的。一个检测思路:让每个节点
强稳定化的收敛速度与证明结构
协议的代价是明确的:
定理 5.11:任何连通的 Chord 网络在
轮强稳定化之内变为强稳定。
很慢,但有一条关键辩护:环套状态本身是极低概率事件,所以在系统的无限生命里,用于从环套状态恢复的时间可以忽略。
证明的两条直觉合起来说明"网络唯一稳定的构型就是想要的那个":
- 首先排除"错的弱稳定":若网络弱稳定但非强稳定,则至少有一个节点在对自己做搜索时会找到一个更好的第二 successor;
- 再排除"非环套"(即某些节点有不止一个 successor 指针):每个节点至少有一个 successor 指针,所以系统里至少有
个指针;只要有一个节点的两个指针不同( successor[0] != successor[1]),系统里就有超过个不同的 successor 指针 —— 于是由鸽巢原理,某个节点 会被两个不同的节点当作 successor;而这不是稳定状态:更近的那个 predecessor 最终会 notify ,之后较远的那个会得知并改指向 。于是唯一稳定的情形是每个节点恰好一个 successor 指针、且指向它在网络中的真实 successor。
中间那块靠两条引理与一条 claim 支撑:
引理 5.12:若网络中存在环套的环,则存在某个节点
,其自查找会揭示一个满足 的节点 。 证明:设环套环为
,按定义存在 且 。因为 是个环,从 反复沿 successor 指针走最终会到达 ;而由于 ,在标识符环的第一次遍历里是找不到 的。更一般地,必然存在 使 不在 的 loop 上。取 为"从 沿 successor 指针走时第一个不在 的 loop 上的节点",于是 ,且 在 的 loop 上。把 的 loop 上的节点记作 (其中 , )。则必存在某个 使 —— 注意 不能落在 区间内,否则 就在 的 loop 上了。若 ,则 的自查找直接给出一个更好的 successor (因为 )。若 ,则 的自查找同样会给出 (它是 的改进),理由是 —— 的自查找路径是 的自查找路径的一条子路径。
引理 5.13:arc path 在强稳定化下同样保持(证明与引理 5.5 一样对系统变化做归纳;加入与自查找只会增加一条边、不会破坏 arc path,替换一条重复的边也不会)。
Claim 5.15:若 Chord 网络连通但非强稳定,则经过
轮稳定化后,某个 successor 指针会得到改进。 证明分三种情形:① 若存在两个不同节点
都以 为 successor,则 稳定化之后 会得到一个 predecessor 满足 ;随后 稳定化时,就会把自己指向 的指针改成指向 ;② 若存在节点有两个不同的 successor 指针,则共有 个不同的 successor 指针,由鸽巢原理回到情形 ①;③ 否则每个节点只指向一个节点、也只被一个节点指向 —— 网络是一组环;因已假设连通,所以是单个环;又因假设非强稳定,这个环是环套的 —— 于是由引理 5.12,一轮自查找就能为某个节点找到更好的 successor。
定理 5.11 的证明接起来:推论 5.14 保证连通性在强稳定化过程中始终成立;Claim 5.15 保证"在达到强稳定之前,总能在
还有一个顺带得出的行为:环套的 Chord 网络在其环合并之前根本不允许新节点加入 —— 因为在环套网络中,对所有 u.on_cycle = false(
自查找那一步原本每轮要 u.find_successor(u + 2^{i-1})。可以归纳地证明
最后是把失败纳入进来:
定理 5.16:从一个带长度
的 successor list 的任意连通状态出发,允许失败以"每 步内至多 个节点"的速率发生,则以高概率在 轮内网络变为强稳定。
这里有一个和弱稳定化不同的新风险:若
双环交错时的快速强稳定化
最可能造出环套图的场景是网络分区 —— 分区彻底切断了部分节点与其他节点的连通。这个问题可以用一个具体模型来研究:从一个弱稳定但由两个环组成的网络出发 —— 即从某个节点
改法很小:修改 u.stabilize(),让
正确性分析(二):动态模型与带失败的稳定化
前面几个定理都有个隐含前提:初始状态是个环。但实践中这个前提不成立 —— 系统里总会有一些刚加入、还没来得及嵌进环里的节点。所以要证一个更强的结果:稳定化算法能让系统持续保持"类环"的状态。为叙述简单,这里限制在同步的稳定化模型下(对下述定义做一点相应调整,就可以处理"多数机器以大致相同速率运行、消息到达时间大致一致"这种程度的异步,且运行时间不增加)。
几个定义先把"不是环"的中间状态描述清楚:
| 概念 | 定义 |
|---|---|
| 一轮(round) | 每个节点运行一次 stabilize() 所需的 |
| pseudoforest | 因为每个节点恰好有一个 successor,successor 指针定义的图必然是一个 pseudoforest —— 所有连通分量都是"朝一个根环(而不是朝一个根节点)指的"有向树 |
| pseudotree | 限制在连通网络上时,该图就是一个 pseudotree |
| appendage | 网络弱稳定时所有节点都在环上;对每个环节点 |
还有一条强制要求:加入的节点必须对一个已经在环上的既有节点调用 u.join(n) —— 可以靠外部设施保证这一点,或者用那个更复杂的 join() 协议。
定义 5.19(pseudostar):满足三条的 Chord 网络 —— ① 环是非环套的;②
,其中 是 在环上的 predecessor;③ 对每个 都有 。
引理 5.20:从一个 pseudostar 出发,在运行
stabilize()的同时执行任意序列的加入,得到的网络仍然是 pseudostar。这个结论比直觉更强:它即便对完全不讲道理的加入也成立 —— 例如加入节点的标识符是被恶意挑选的、而不服从随机分布。
紧接着是那个关键的"类环状态"定义。这一串条件看起来繁琐,但每一条都对应后面某个论证要用到的性质:
定义 5.21(
-ring-like state):对某个常数 —— ① 网络是 pseudostar; ② 加入网络至少 轮的节点:(a) 全都在环上;(b) 占网络中至少一半;(c) 在标识符环上独立均匀分布;(d) 永不落入区间 ; ③ 在最近 轮内加入的节点:(a) 在标识符环上独立均匀分布;(b) 对环上任意连续节点 ,有 。 增大常数
会提高本节结论里 成功概率的指数。
有一处不能过度声称的地方:近期加入的节点被纳入环的顺序可能存在偏差 —— 例如"靠近已在环上的节点的节点"、以及"落在环上两个互相靠近的节点之间的节点",会更早被纳入;所以不能说环上所有节点的分布是均匀且独立的:
出于技术原因,我们考虑一个强稳定、finger 正确、且所有节点标识符独立均匀选取的网络处于类环状态。
引理 5.22:从含
个节点的 -ring-like state 出发,允许在至少 轮内于任意时刻发生最多 次随机加入,则以高概率我们最终仍处在 -ring-like state(要提高成功概率就把 调大)。
证明的思路是把节点按"在线时长"分成三代:"老"节点(在场超过
- 按定义,老节点都在环上;
- 由标识符随机可知,任意两个老节点之间只会有
个节点加入; - 由条件 ②d,finger 指针相对于老节点是正确的 —— 即没有任何 finger 指针跳过某个老节点;
- 于是老节点恰好扮演了上一节分析里"初始那个环"的角色:因为只加入
个节点,指向老节点的那些 finger 就足够为新节点快速路由查找; - 这推出在上述时间窗内没有任何节点的 appendage 会变得过大;而既然 appendage 都不大、且每轮至少有一个节点从每个 appendage 上脱落进入环,那么在分析开始时在场的那些节点(即中年节点)都会在变成"老"之前进入环 —— 类环状态的不变量因此得以保持;
- 中年节点全部进入环之后,还需额外
轮稳定化来保证 finger 相对于中年节点也正确(由上一步可知 fix_fingers()很快)。
失败:successor list 多久需要抄一次
先看一个纯失败的模型(这样的环显然撑不了多久,但它回答的是"successor list 该多久抄一次")。
引理 5.23:设一个
节点 Chord 环里每个节点都有长度 的 successor list、且至少含有环上接下来 个存活 successor。假设任意挑选的 个节点(与它们的标识符无关)在"每个节点执行 次 successor list 复制"的这段时间内失败。那么在结束时,每个节点仍然持有一个含环上接下来 个存活 successor 的 successor list。 证明:因为失败与标识符无关,失败在标识符空间里是随机的 —— 于是以高概率,对每个
, 的 successor list 里那 个活节点中至少有一个在整个这批失败期间活下来,环因此保持连通。接下来是个可以被反复引用的性质:归纳可知,出现在 的 successor list 第 位的节点,在 轮之前是活着的。所以经过 轮之后,在这批失败之前就已下线的节点不会再出现在任何 successor list 里。于是此时 的 successor list 含有的是"在这段过程的起点活着、且位于 之后的前 个节点"(若其中有些随后也失败了,就由环上更远处的节点替补)。而以高概率这 个节点里失败的不到一半,所以 的 list 里至少含有环上接下来 个存活 successor。
把失败正式纳入类环状态,需要把定义再加固一层:
定义 5.24(robust strongly non-loopy pseudotree):一个带 successor list 的 pseudotree,满足 ① 环是非环套的;②
;③ 对每个 有 ;④ 若 的 successor list 跳过了区间 中的某个活节点 ,则 不在 的 successor list 里。
引理 5.25:从一个 robust strongly non-loopy pseudotree 出发,在运行
stabilize()的同时执行任意序列的加入与失败,则只要得到的网络仍然连通,它就仍是一个 robust strongly non-loopy pseudotree。同样地,它即便对完全不讲道理的加入与失败也成立 —— 例如失败节点的选择与加入节点的标识符都是被恶意挑选的、不服从随机分布 —— 只要网络保持连通。
这里引出一个很实用的判据:
在 successor list 长度为
的网络里,称节点 完全并入(fully incorporated)环,当且仅当它已经在环上停留了至少 个连续轮。
理由给得很直白:仅仅"某个环节点 u.successor = v 之后立刻失败,
定义 5.26(带 successor list 的类环状态):网络有长度
的 successor list,且 ① 每个节点的"第一个存活 successor"所定义的图是一个 robust strongly non-loopy pseudotree; ② 加入至少 轮的节点:(a) 全部完全并入环;(b) 占至少一半;(c) 独立均匀分布;(d) 永不落入 ; ③ 在最近 轮内加入的节点:(a) 独立均匀分布;(b) 任意连续 个环上节点满足 ; ④ 每个节点 的 successor list:(a) 不含失败超过 轮的节点;(b) 含在最近 轮内失败的节点不超过 个;(c) 含 中每一个在至少 轮前成功进入环的活节点; ⑤ 节点失败在所有当时存在的节点中独立均匀。
引理 5.27:在失败独立且均匀地取自所有当时存活节点的前提下,对任意
,运行 stabilize()只会降低"的 successor list 里超过 个节点会失败"这一事件的概率(对任意节点 )。 证明(这一段解释的是"为什么抄 successor list 是有益的",值得跟一遍):对任意节点
,考虑 的 successor list 里第 项 。归纳可知 在 轮稳定化之前是活着的 —— 因为它当时被放进了 的 successor list。我们不知道 在刚过去的 轮里是否失败。在时刻 , u.stabilize()把调整到一个活节点 ,并调用 u.fix_successor_list()把的 list 尾部替换成 的 list。由上可知, 的 list 在时刻 的第 项(也就是 的 list 在时刻 的第 项)在第 轮是活着的;而 的 list 里原来那个第 项是在第 轮活着的,它有可能在第 轮失败了。在随机失败的假设下,这就意味着" 的 list 第 项已经失败"的概率下降了。而若两者在时刻 都活着,则由随机失败,其中一个在任意 失败的概率与另一个相同。
最后一个结构性事实:当环节点
评测
模拟器实现的是迭代式(由发起查找的节点发起全部通信)而非递归式(每个中间节点向下转发)。
负载均衡:一致性哈希的偏差有多大
| 指标 | 数值 |
|---|---|
| 单节点最多存的 key 数 | 457,即均值的 9.1 倍 |
| 第 99 百分位 | 均值的 4.6 倍 |
波动的一个原因是节点标识符并没有均匀覆盖整个标识符空间。把标识符空间分成
虚拟节点的实测(
路径长度:为什么是
把节点到查询目标的距离用二进制表示。距离的最高位(第
位)可以通过走该节点的第 个 finger 修正为 0。 如果距离的下一个有效位是 1,那它也需要走一个 finger 来修正;但如果是 0,就不走第 个 finger,而是直接跳到第 位。一般地,需要走的 finger 数就是"节点到查询目标的距离"的二进制表示里 1 的个数。距离是随机的,所以平均而言 位里有一半是 1。
同时失败
查找失败率几乎正好等于
稳定化期间的查找
评估口径先声明清楚:只要查询到达了目标 key 当前的 successor 就算成功(这略偏乐观 —— 真实系统里可能存在"真正的 successor 还没来得及从旧 successor 那里拿到数据"的时段);模拟器不重试查询,所以这一节数字可以看成状态不一致导致的查询失败的最坏情况。
参数:key 查找按每秒 1 次的泊松过程产生;join 与 failure 按均值为
一个可以手算的估算:500 个节点时路径长度约 5。若
Internet 原型
部署在美国 RON 测试床的十个站点上(加州、科罗拉多、马萨诸塞、纽约、北卡、宾州),跑在 UNIX 上,用 SHA-1 生成的 160 位 key,节点间用 TCP,采用迭代式。节点数超过 10 的实验靠在每个站点跑多份独立副本来做 —— 注意这不同于"每个站点跑
中位延迟在 180 到 285 ms 之间(随节点数变化)。对 180 个节点这一档:一次典型查找涉及五次双向消息交换 —— 四次用于 Chord 查找、最后一次发给 successor 节点;站点间典型往返延迟 60 ms;于是预期查找时间约 300 ms,与测得的中位 285 ms 接近。低 5 百分位来自"key 在 ID 空间上离查询节点很近"以及"跳数停留在本物理站点内";高 95 百分位来自走了高延迟路径的那些跳。
相关
- CAN —— 同年(SIGCOMM 2001)的另一条路,正好构成正面对照:CAN 放弃
的路径长度,换来每节点状态与 无关( 个邻居);Chord 接受 状态以换对数级路径。CAN 把位置摊在 维坐标空间里(邻居靠几何关系),Chord 把位置摊在一维环上(邻居靠 finger 的 2 的幂结构) - 一致性哈希算法 —— 本专栏的前置:那篇讲的环、虚拟节点、重映射界,正是 Chord 直接拿来用的工具。Chord 的增量在于**"每个节点不必知道几乎所有其他节点"** —— 用 finger table 把查找变成
- Dynamo —— 一致性哈希在生产系统里的用法:Dynamo 同样用环 + 虚拟节点,但它为了写可用性放弃了强一致(用向量时钟与 sloppy quorum 处理冲突),而 Chord 只是查找原语、不承诺任何数据语义(认证/缓存/复制全交给应用)
- Cassandra —— 从 Dynamo 那条线演化,但它把随机哈希换成了保序哈希 —— 这个改动正是为了让范围查询能在环上顺序进行,代价是放弃了"哈希均匀"带来的负载均衡
- Ceph —— CRUSH 与本篇的 finger table 是两种"让节点自己算出位置"的结构:CRUSH 是纯函数、节点之间不需要维持路由表,代价是必须有一张分层的 cluster map;Chord 的路由表是动态维护的局部状态,代价是稳定化协议
参考
- I. Stoica, R. Morris, D. Karger, M. F. Kaashoek, H. Balakrishnan. Chord: A Scalable Peer-to-Peer Lookup Service for Internet Applications. SIGCOMM 2001.
YJ